// This Pine Script® code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © NeBlok

//@version=6
indicator("Wyckoff Range Detector 3D [Neblok]", shorttitle = "Wyckoff 3D [Neblok]", overlay = true, max_boxes_count = 200, max_lines_count = 300, max_labels_count = 500, max_polylines_count = 100)

// =====================================================================
//  Wyckoff Range Detector 3D [Neblok]
//  Automatically finds Wyckoff accumulation and distribution ranges and
//  labels their events as they happen:
//
//  ACCUMULATION: SC → AR → ST → SPRING → TEST → SOS → LPS
//  DISTRIBUTION: BC → AR → ST → UTAD   → TEST → SOW → LPSY
//
//  One state machine runs both schematics. Distribution is handled by
//  mirroring prices (x → -x), so both sides use identical rules.
//  "Effort" = volume, or bar range on symbols without volume.
// =====================================================================

// ── Input groups ──────────────────────────────────────────────────────
string G_CLM = "Climax Detection"
string G_RNG = "Range Rules"
string G_EVT = "Event Rules"
string G_DSP = "Display"
string G_3D  = "3D Rendering"
string G_COL = "Neon Colors"
string G_TBL = "Dashboard"
string G_ALR = "Alerts"

// ── Climax ────────────────────────────────────────────────────────────
climaxZ   = input.float(2.5, "Climax Effort (z-score)", minval = 0.5, step = 0.1, group = G_CLM, tooltip = "How unusual the volume (or range, if no volume) must be versus its recent average. Higher = fewer, stronger climaxes.")
climaxRng = input.float(1.2, "Climax Bar Range (× ATR)", minval = 0.0, step = 0.1, group = G_CLM)
trendLook = input.int(50, "Fresh Extreme Lookback (bars)", minval = 5, group = G_CLM, tooltip = "A selling climax must make the lowest low (buying climax: highest high) of this many bars.")
emaLen    = input.int(50, "Trend EMA Length", minval = 5, group = G_CLM, tooltip = "Selling climax must close below this EMA (downtrend); buying climax above it (uptrend).")
volLen    = input.int(50, "Effort Baseline Length", minval = 10, group = G_CLM)
atrLen    = input.int(14, "ATR Length", minval = 1, group = G_CLM)

// ── Range ─────────────────────────────────────────────────────────────
pivLen       = input.int(5, "Pivot Strength (bars each side)", minval = 2, maxval = 20, group = G_RNG, tooltip = "Pivot-based events (AR, ST, TEST, LPS/LPSY) are confirmed this many bars after the pivot.")
minRangeAtr  = input.float(3.0, "Min Range Height (× ATR)", minval = 0.5, step = 0.25, group = G_RNG)
arMaxBars    = input.int(60, "Max Bars To Find AR", minval = 5, group = G_RNG)
maxRangeBars = input.int(400, "Max Range Duration (bars)", minval = 20, group = G_RNG)

// ── Events ────────────────────────────────────────────────────────────
stZone      = input.float(0.35, "ST Zone (fraction of range)", minval = 0.05, maxval = 0.9, step = 0.05, group = G_EVT, tooltip = "Secondary tests must form within this fraction of the range height from the climax side.")
maxST       = input.int(2, "Max ST Labels Per Range", minval = 0, maxval = 5, group = G_EVT)
requireST   = input.bool(true, "Require ST Before Spring / UTAD", group = G_EVT, tooltip = "Wyckoff springs happen in Phase C, after the range has been tested. With this on, a break-and-reclaim before the first ST just extends the climax low/high instead of counting as a Spring/UTAD.")
reclaimBars = input.int(5, "Spring / UTAD Reclaim Window (bars)", minval = 1, maxval = 30, group = G_EVT, tooltip = "After price breaks the range edge, it must close back inside within this many bars to count as a Spring / UTAD.")
breakAtr    = input.float(0.5, "Decisive Break Distance (× ATR)", minval = 0.1, step = 0.1, group = G_EVT, tooltip = "A close this far beyond the range edge ends the range as failed.")
testZone    = input.float(0.4, "Test Zone (fraction of range)", minval = 0.05, maxval = 0.9, step = 0.05, group = G_EVT)
sosBufAtr   = input.float(0.1, "SOS / SOW Close Beyond Range (× ATR)", minval = 0.0, step = 0.05, group = G_EVT)
sosMinZ     = input.float(0.5, "Min SOS / SOW Effort (z-score)", step = 0.1, group = G_EVT, tooltip = "The breakout bar needs at least this much effort, or a range of 1 ATR or more. Filters out drifting, low-volume breakouts.")
lpsMaxBars  = input.int(60, "Max Bars To Find LPS / LPSY", minval = 5, group = G_EVT)
projMult    = input.float(1.0, "Target Projection (× range height)", minval = 0.25, step = 0.25, group = G_EVT)
projBars    = input.int(40, "Target Line Length (bars)", minval = 5, maxval = 300, group = G_EVT)

// ── Display ───────────────────────────────────────────────────────────
maxRanges     = input.int(3, "Past Ranges To Keep", minval = 1, maxval = 40, group = G_DSP, tooltip = "Each range's labels are removed together with the range.")
showFailed    = input.bool(false, "Show Failed / Expired Ranges", group = G_DSP, tooltip = "Off = failed and expired ranges are removed from the chart (they are still counted in the dashboard).")
showSpringLbl = input.bool(false, "Show Spring / UTAD Labels", group = G_DSP, tooltip = "Off = Springs / UTADs are marked only by yellow triangles.")
detailLbl     = input.bool(false, "Detailed Labels", group = G_DSP, tooltip = "Adds a second line to labels (effort z-score, spring depth, etc.).")
showMid       = input.bool(true, "Show Range Midline", group = G_DSP)
showTarget    = input.bool(true, "Show Breakout Target", group = G_DSP)
showMarkers   = input.bool(true, "Show Signal Triangles", group = G_DSP)
labelSizeIn   = input.string("Normal", "Event Label Size", options = ["Small", "Normal", "Large", "Huge"], group = G_DSP)
signalSizeIn  = input.string("Large", "LPS / LPSY / Spring Label Size", options = ["Small", "Normal", "Large", "Huge"], group = G_DSP)

// ── 3D ────────────────────────────────────────────────────────────────
show3D    = input.bool(true, "3D Range Blocks", group = G_3D)
depthBars = input.int(6, "3D Depth (bars)", minval = 1, maxval = 40, group = G_3D)
liftAtr   = input.float(0.4, "3D Lift (× ATR)", minval = 0.0, step = 0.05, group = G_3D)

// ── Colors (flat neon, no gradients) ──────────────────────────────────
accCol   = input.color(#39FF14, "Accumulation Neon", group = G_COL)
distCol  = input.color(#FF1E8C, "Distribution Neon", group = G_COL)
sigCol   = input.color(#FFF01F, "Spring / UTAD Neon", group = G_COL)
climCol  = input.color(#00F0FF, "Climax Neon", group = G_COL)
failCol  = input.color(#FF7A00, "Failure Neon", group = G_COL)
panelCol = input.color(#05060D, "Panel Background", group = G_COL)

// ── Dashboard ─────────────────────────────────────────────────────────
showTable   = input.bool(true, "Show Dashboard", group = G_TBL)
tablePosIn  = input.string("Top Right", "Position", options = ["Top Right", "Top Left", "Bottom Right", "Bottom Left", "Middle Right"], group = G_TBL)
tableSizeIn = input.string("Normal", "Text Size", options = ["Small", "Normal", "Large", "Huge"], group = G_TBL)

// ── Alerts ────────────────────────────────────────────────────────────
alertSig    = input.bool(true, "Alert: Spring / UTAD / SOS / SOW / LPS / LPSY", group = G_ALR)
alertStruct = input.bool(true, "Alert: Climax, Range Confirmed, Range Failed", group = G_ALR)

// =====================================================================
//  Types & state
// =====================================================================
type RangeDraw
    box          b
    line         mid
    line         tgt
    label        tgtLbl
    label        ph
    polyline     p1
    polyline     p2
    array<label> lbls

var int   state    = 0     // 0 scanning, 1 awaiting AR, 2 range active
var int   dir      = 0     // +1 accumulation, -1 distribution
var int   phase    = 0     // 1..5 = Phase A..E
var float rTop     = na    // range edges in mirrored space
var float rBot     = na
var int   climBar  = 0
var float climEffZ = 0.0
var label climLbl  = na
var int   arBar    = 0

var bool  brkPending = false
var float brkLow     = na
var int   brkStart   = 0
var int   brkLowBar  = 0
var float brkLowEffZ = 0.0

var bool  springSeen = false
var bool  testSeen   = false
var bool  sosSeen    = false
var float springLow  = na
var float springEffZ = 0.0
var int   stCount    = 0
var int   sosBar     = 0

var box   rangeBox = na
var line  midLine  = na
var label phaseLbl = na
var line  tgtLine  = na
var label tgtLbl   = na

var array<label>     curLbls   = array.new<label>()   // labels of the live range
var array<RangeDraw> history   = array.new<RangeDraw>()
var array<polyline>  livePolys = array.new<polyline>()
var table            tbl       = na

// Outcome counters
var int nAcc      = 0
var int nDist     = 0
var int accHeld   = 0
var int accFail   = 0
var int distHeld  = 0
var int distFail  = 0
var int springN   = 0
var int springWin = 0
var int utadN     = 0
var int utadWin   = 0

// =====================================================================
//  Helpers
// =====================================================================
f_size(string s) =>
    switch s
        "Small"  => size.small
        "Normal" => size.normal
        "Large"  => size.large
        "Huge"   => size.huge
        => size.normal

f_pos(string s) =>
    switch s
        "Top Right"    => position.top_right
        "Top Left"     => position.top_left
        "Bottom Right" => position.bottom_right
        "Bottom Left"  => position.bottom_left
        "Middle Right" => position.middle_right
        => position.top_right

f_nm(string acc, string dist) =>
    dir == 1 ? acc : dist

f_phase(int p) =>
    p == 1 ? "A" : p == 2 ? "B" : p == 3 ? "C" : p == 4 ? "D" : "E"

f_chk(bool b) =>
    b ? "✓" : "–"

// Event label, registered to the live range so it is removed with it.
// tx is in mirrored space; atLowT = event sits on the climax side
// of the range (lows for accumulation, highs for distribution)
f_ev(int x, float tx, string txt, bool atLowT, color bg, string sz) =>
    bool  below = atLowT == (dir == 1)
    label lb    = label.new(x, dir * tx, txt, xloc = xloc.bar_index, color = bg, style = below ? label.style_label_up : label.style_label_down, textcolor = color.black, size = sz, text_font_family = font.family_monospace)
    curLbls.push(lb)
    lb

// 3D top face + side face for a range block
f_faces(int l, int r, float topR, float botR, color c, float a) =>
    float dy = a * liftAtr
    int   dx = depthBars
    array<chart.point> tp = array.new<chart.point>()
    tp.push(chart.point.from_index(l, topR))
    tp.push(chart.point.from_index(r, topR))
    tp.push(chart.point.from_index(r + dx, topR + dy))
    tp.push(chart.point.from_index(l + dx, topR + dy))
    array<chart.point> sp = array.new<chart.point>()
    sp.push(chart.point.from_index(r, topR))
    sp.push(chart.point.from_index(r + dx, topR + dy))
    sp.push(chart.point.from_index(r + dx, botR + dy))
    sp.push(chart.point.from_index(r, botR))
    polyline p1 = polyline.new(points = tp, curved = false, closed = true, xloc = xloc.bar_index, line_color = c, fill_color = color.new(c, 62), line_width = 1)
    polyline p2 = polyline.new(points = sp, curved = false, closed = true, xloc = xloc.bar_index, line_color = c, fill_color = color.new(c, 80), line_width = 1)
    [p1, p2]

// =====================================================================
//  Core series
// =====================================================================
float atr       = ta.atr(atrLen)
float ema       = ta.ema(close, emaLen)
float eff       = nz(volume) > 0 ? volume : high - low
float effMean   = ta.sma(eff, volLen)
float effSd     = ta.stdev(eff, volLen)
float effZ      = effSd > 0 ? (eff - effMean) / effSd : 0.0
float ph        = ta.pivothigh(high, pivLen, pivLen)
float pl        = ta.pivotlow(low, pivLen, pivLen)
float priorLow  = ta.lowest(low, trendLook)[1]
float priorHigh = ta.highest(high, trendLook)[1]
bool  wideBar   = (high - low) >= climaxRng * atr
bool  scBar     = effZ >= climaxZ and wideBar and low < priorLow and close < ema
bool  bcBar     = effZ >= climaxZ and wideBar and high > priorHigh and close > ema

string lblSize  = f_size(labelSizeIn)
string sigSize  = f_size(signalSizeIn)
string tfTxt    = syminfo.ticker + " " + timeframe.period

string alertMsg    = ""
bool   sigSpring   = false
bool   sigUtad     = false
bool   sigLps      = false
bool   sigLpsy     = false
bool   finalizeNow = false
bool   hideRange   = false
string finalTag    = ""

// =====================================================================
//  State 0: scan for a selling / buying climax
// =====================================================================
if state == 0 and (scBar or bcBar)
    dir      := scBar ? 1 : -1
    state    := 1
    rBot     := dir == 1 ? low : -high
    climBar  := bar_index
    climEffZ := effZ
    climLbl  := label.new(bar_index, dir == 1 ? low : high, dir == 1 ? "SC" : "BC", xloc = xloc.bar_index, color = climCol, style = dir == 1 ? label.style_label_up : label.style_label_down, textcolor = color.black, size = lblSize, text_font_family = font.family_monospace)
    if alertStruct
        alertMsg += (dir == 1 ? "Selling Climax (SC)" : "Buying Climax (BC)") + " detected | " + tfTxt + "\n"

// Mirrored values: accumulation uses prices as-is, distribution uses -price
float tH   = dir == 1 ? high : -low
float tL   = dir == 1 ? low : -high
float tC   = dir == 1 ? close : -close
float tPH  = dir == 1 ? ph : -pl
float tPL  = dir == 1 ? pl : -ph
int   pb   = bar_index - pivLen
color dCol = dir == 1 ? accCol : distCol

// =====================================================================
//  State 1: climax found, waiting for the Automatic Rally / Reaction
// =====================================================================
if state == 1 and bar_index > climBar
    if tL < rBot
        rBot     := tL
        climBar  := bar_index
        climEffZ := math.max(climEffZ, effZ)
        label.set_xy(climLbl, bar_index, dir * tL)
    if not na(tPH) and pb > climBar and tPH - rBot >= minRangeAtr * atr
        rTop       := tPH
        arBar      := pb
        state      := 2
        phase      := 1
        brkPending := false
        springSeen := false
        testSeen   := false
        sosSeen    := false
        stCount    := 0
        tgtLine    := na
        tgtLbl     := na
        curLbls.push(climLbl)
        f_ev(pb, tPH, "AR", false, dCol, lblSize)
        float topR = math.max(dir * rTop, dir * rBot)
        float botR = math.min(dir * rTop, dir * rBot)
        rangeBox := box.new(climBar, topR, bar_index, botR, xloc = xloc.bar_index, border_color = dCol, border_width = 2, bgcolor = color.new(dCol, 88))
        midLine  := showMid ? line.new(climBar, (topR + botR) / 2.0, bar_index, (topR + botR) / 2.0, xloc = xloc.bar_index, color = color.new(dCol, 30), style = line.style_dashed, width = 1) : na
        phaseLbl := label.new(climBar, topR, "", xloc = xloc.bar_index, color = color.new(panelCol, 10), style = label.style_label_lower_left, textcolor = dCol, size = lblSize, text_font_family = font.family_monospace)
        if dir == 1
            nAcc += 1
        else
            nDist += 1
        if alertStruct
            alertMsg += f_nm("ACCUMULATION", "DISTRIBUTION") + " range confirmed (AR) | " + str.tostring(botR, format.mintick) + " – " + str.tostring(topR, format.mintick) + " | " + tfTxt + "\n"
    else if bar_index - climBar > arMaxBars
        label.delete(climLbl)
        state := 0
        dir   := 0

// =====================================================================
//  State 2: range active — hunt the Wyckoff events
// =====================================================================
if state == 2 and bar_index > arBar + pivLen
    float height = rTop - rBot
    float rMid   = (rTop + rBot) / 2.0

    // ── Pivot-confirmed events: ST, TEST, LPS / LPSY ──────────────────
    if not na(tPL) and pb > arBar
        float pEffZ = effZ[pivLen]
        if sosSeen
            if pb > sosBar and tPL >= rMid
                f_ev(pb, tPL, f_nm("▲ LPS", "▼ LPSY") + (detailLbl ? "\nentry zone" : ""), true, dCol, sigSize)
                if dir == 1
                    sigLps  := true
                    accHeld += 1
                else
                    sigLpsy  := true
                    distHeld += 1
                if alertSig
                    alertMsg += f_nm("LPS (Last Point of Support)", "LPSY (Last Point of Supply)") + " confirmed at " + str.tostring(dir * tPL, format.mintick) + " | " + tfTxt + "\n"
                phase       := 5
                finalizeNow := true
                finalTag    := f_nm("MARKUP", "MARKDOWN")
        else if springSeen
            if not testSeen and tPL > springLow and tPL <= rBot + testZone * height and pEffZ < springEffZ
                f_ev(pb, tPL, "TEST", true, dCol, lblSize)
                testSeen := true
        else if stCount < maxST and tPL >= rBot and tPL <= rBot + stZone * height
            f_ev(pb, tPL, detailLbl and pEffZ >= climEffZ ? "ST\nhi vol" : "ST", true, dCol, lblSize)
            stCount += 1
            if phase < 2
                phase := 2

    // ── Bar-level events: Spring / UTAD, failure, SOS / SOW ───────────
    if not sosSeen and not finalizeNow
        if tL < rBot and not brkPending
            brkPending := true
            brkStart   := bar_index
            brkLow     := tL
            brkLowBar  := bar_index
            brkLowEffZ := effZ
        if brkPending
            if tL < brkLow
                brkLow     := tL
                brkLowBar  := bar_index
                brkLowEffZ := effZ
            if tC > rBot
                brkPending := false
                if requireST and stCount == 0
                    // Still Phase A: the climax extreme is being extended, not sprung
                    rBot     := brkLow
                    climEffZ := math.max(climEffZ, brkLowEffZ)
                    label.set_xy(climLbl, brkLowBar, dir * brkLow)
                    float topX = math.max(dir * rTop, dir * rBot)
                    float botX = math.min(dir * rTop, dir * rBot)
                    box.set_top(rangeBox, topX)
                    box.set_bottom(rangeBox, botX)
                    if not na(midLine)
                        line.set_y1(midLine, (topX + botX) / 2.0)
                        line.set_y2(midLine, (topX + botX) / 2.0)
                    label.set_y(phaseLbl, topX)
                else
                    // Price reclaimed the range: Spring (acc) / UTAD (dist)
                    springSeen := true
                    testSeen   := false
                    springLow  := brkLow
                    springEffZ := brkLowEffZ
                    phase      := 3
                    float  depth = (rBot - brkLow) / atr
                    string note  = brkLowEffZ < 1.0 ? "low vol" : "shakeout"
                    if showSpringLbl
                        f_ev(brkLowBar, brkLow, f_nm("SPRING", "UTAD") + (detailLbl ? "\n" + note + " · " + str.tostring(depth, "#.##") + " ATR" : ""), true, sigCol, sigSize)
                    if dir == 1
                        sigSpring := true
                        springN   += 1
                    else
                        sigUtad := true
                        utadN   += 1
                    if alertSig
                        alertMsg += f_nm("SPRING", "UTAD") + " confirmed (" + note + ") at " + str.tostring(dir * brkLow, format.mintick) + " | " + tfTxt + "\n"
            else if tC < rBot - breakAtr * atr or bar_index - brkStart >= reclaimBars
                f_ev(bar_index, tL, f_nm("✖ BREAKDOWN", "✖ BREAKOUT") + (detailLbl ? "\nrange failed" : ""), true, failCol, lblSize)
                if dir == 1
                    accFail += 1
                else
                    distFail += 1
                if alertStruct
                    alertMsg += f_nm("Accumulation", "Distribution") + " range FAILED | " + tfTxt + "\n"
                brkPending  := false
                finalizeNow := true
                hideRange   := not showFailed
                finalTag    := "FAILED"

        bool strongBar = effZ >= sosMinZ or (high - low) >= atr
        if not finalizeNow and not brkPending and tC > rTop + sosBufAtr * atr and strongBar
            f_ev(bar_index, tH, f_nm("SOS", "SOW") + (detailLbl ? "\neffort z " + str.tostring(effZ, "#.#") : ""), false, dCol, lblSize)
            sosSeen := true
            sosBar  := bar_index
            phase   := 4
            if springSeen
                if dir == 1
                    springWin += 1
                else
                    utadWin += 1
            if showTarget
                float tgtR = dir * (rTop + height * projMult)
                tgtLine := line.new(bar_index, tgtR, bar_index + projBars, tgtR, xloc = xloc.bar_index, color = dCol, style = line.style_dashed, width = 2)
                tgtLbl  := label.new(bar_index + projBars, tgtR, "TARGET  " + str.tostring(tgtR, format.mintick), xloc = xloc.bar_index, color = color.new(panelCol, 10), style = label.style_label_left, textcolor = dCol, size = lblSize, text_font_family = font.family_monospace)
            if alertSig
                alertMsg += f_nm("SOS (Sign of Strength) breakout", "SOW (Sign of Weakness) breakdown") + " at " + str.tostring(close, format.mintick) + " | " + tfTxt + "\n"

    else if sosSeen and not finalizeNow
        if bar_index > sosBar and tC < rMid
            f_ev(bar_index, tL, f_nm("✖ FAILED SOS", "✖ FAILED SOW"), true, failCol, lblSize)
            if dir == 1
                accFail += 1
            else
                distFail += 1
            if alertStruct
                alertMsg += f_nm("SOS", "SOW") + " FAILED — back inside range | " + tfTxt + "\n"
            finalizeNow := true
            hideRange   := not showFailed
            finalTag    := "FAILED BREAKOUT"
        else if bar_index - sosBar > lpsMaxBars
            if dir == 1
                accHeld += 1
            else
                distHeld += 1
            phase       := 5
            finalizeNow := true
            finalTag    := f_nm("MARKUP (no LPS)", "MARKDOWN (no LPSY)")

    // ── Range timeout ─────────────────────────────────────────────────
    if not finalizeNow and not sosSeen and bar_index - climBar > maxRangeBars
        finalizeNow := true
        hideRange   := not showFailed
        finalTag    := "EXPIRED"

    // ── Extend / update the live range ────────────────────────────────
    box.set_right(rangeBox, bar_index)
    if not na(midLine)
        line.set_x2(midLine, bar_index)
    label.set_text(phaseLbl, f_nm("ACCUMULATION", "DISTRIBUTION") + " · PHASE " + f_phase(phase))

    // ── Finalize a completed range ────────────────────────────────────
    if finalizeNow
        if hideRange
            // Failed / expired range: remove everything (still counted in stats)
            box.delete(rangeBox)
            if not na(midLine)
                line.delete(midLine)
            if not na(tgtLine)
                line.delete(tgtLine)
            if not na(tgtLbl)
                label.delete(tgtLbl)
            label.delete(phaseLbl)
            while curLbls.size() > 0
                curLbls.pop().delete()
        else
            label.set_text(phaseLbl, f_nm("ACCUMULATION", "DISTRIBUTION") + " · " + finalTag)
            polyline fp1 = na
            polyline fp2 = na
            if show3D
                float topR = math.max(dir * rTop, dir * rBot)
                float botR = math.min(dir * rTop, dir * rBot)
                [a1, a2] = f_faces(climBar, bar_index, topR, botR, dCol, nz(atr))
                fp1 := a1
                fp2 := a2
            history.push(RangeDraw.new(rangeBox, midLine, tgtLine, tgtLbl, phaseLbl, fp1, fp2, curLbls.copy()))
            curLbls.clear()
            if history.size() > maxRanges
                RangeDraw old = history.shift()
                if not na(old.b)
                    old.b.delete()
                if not na(old.mid)
                    old.mid.delete()
                if not na(old.tgt)
                    old.tgt.delete()
                if not na(old.tgtLbl)
                    old.tgtLbl.delete()
                if not na(old.ph)
                    old.ph.delete()
                if not na(old.p1)
                    old.p1.delete()
                if not na(old.p2)
                    old.p2.delete()
                for lb in old.lbls
                    lb.delete()
        state      := 0
        dir        := 0
        rangeBox   := na
        midLine    := na
        phaseLbl   := na
        tgtLine    := na
        tgtLbl     := na
        brkPending := false

// =====================================================================
//  Live 3D block + dashboard (last bar)
// =====================================================================
if barstate.islast
    while livePolys.size() > 0
        livePolys.pop().delete()
    if state == 2 and show3D
        float topR = math.max(dir * rTop, dir * rBot)
        float botR = math.min(dir * rTop, dir * rBot)
        [l1, l2] = f_faces(climBar, bar_index, topR, botR, dCol, nz(atr))
        livePolys.push(l1)
        livePolys.push(l2)

    if showTable
        tSize = f_size(tableSizeIn)
        if na(tbl)
            tbl := table.new(f_pos(tablePosIn), 2, 7, bgcolor = panelCol, frame_color = climCol, frame_width = 2, border_color = color.new(climCol, 70), border_width = 1)
            table.merge_cells(tbl, 0, 0, 1, 0)

        string stTxt  = "SCANNING FOR CLIMAX"
        color  stCol  = color.white
        string rngTxt = "—"
        string evTxt  = "—"
        if state == 1
            stTxt := (dir == 1 ? "SC" : "BC") + " FOUND · AWAITING AR"
            stCol := climCol
        else if state == 2
            float topR = math.max(dir * rTop, dir * rBot)
            float botR = math.min(dir * rTop, dir * rBot)
            stTxt  := f_nm("ACCUMULATION", "DISTRIBUTION") + " · PHASE " + f_phase(phase)
            stCol  := dCol
            rngTxt := str.tostring(botR, format.mintick) + " – " + str.tostring(topR, format.mintick) + "  (" + str.tostring((topR - botR) / atr, "#.#") + " ATR)"
            evTxt  := f_nm("SC", "BC") + " ✓  AR ✓  ST " + str.tostring(stCount) + "  " + f_nm("SPRING ", "UTAD ") + f_chk(springSeen) + "  TEST " + f_chk(testSeen) + "  " + f_nm("SOS ", "SOW ") + f_chk(sosSeen)

        string sprTxt  = springN > 0 ? str.tostring(100.0 * springWin / springN, "#") + "%  (" + str.tostring(springWin) + "/" + str.tostring(springN) + ")" : "no springs yet"
        string utTxt   = utadN > 0 ? str.tostring(100.0 * utadWin / utadN, "#") + "%  (" + str.tostring(utadWin) + "/" + str.tostring(utadN) + ")" : "no UTADs yet"
        string histTxt = "ACC " + str.tostring(nAcc) + " (held " + str.tostring(accHeld) + " / failed " + str.tostring(accFail) + ")   DIST " + str.tostring(nDist) + " (held " + str.tostring(distHeld) + " / failed " + str.tostring(distFail) + ")"

        table.cell(tbl, 0, 0, "WYCKOFF RANGE DETECTOR 3D", text_color = sigCol, text_size = tSize, bgcolor = panelCol)
        table.cell(tbl, 0, 1, "STATUS", text_color = color.white, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 1, 1, stTxt, text_color = stCol, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 0, 2, "RANGE", text_color = color.white, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 1, 2, rngTxt, text_color = stCol, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 0, 3, "EVENTS", text_color = color.white, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 1, 3, evTxt, text_color = stCol, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 0, 4, "SPRING → SOS", text_color = accCol, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 1, 4, sprTxt, text_color = accCol, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 0, 5, "UTAD → SOW", text_color = distCol, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 1, 5, utTxt, text_color = distCol, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 0, 6, "RANGES", text_color = color.white, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)
        table.cell(tbl, 1, 6, histTxt, text_color = color.white, text_size = tSize, bgcolor = panelCol, text_halign = text.align_left)

// =====================================================================
//  Signal markers & alerts
// =====================================================================
plotshape(showMarkers and sigSpring, title = "Spring", style = shape.triangleup, location = location.belowbar, color = sigCol, size = size.small)
plotshape(showMarkers and sigUtad, title = "UTAD", style = shape.triangledown, location = location.abovebar, color = sigCol, size = size.small)
plotshape(showMarkers and sigLps, title = "LPS", style = shape.triangleup, location = location.belowbar, color = accCol, size = size.small)
plotshape(showMarkers and sigLpsy, title = "LPSY", style = shape.triangledown, location = location.abovebar, color = distCol, size = size.small)

if alertMsg != ""
    alert(alertMsg, alert.freq_once_per_bar_close)
